import pytest
from unittest import mock
import os
import json
import re
from collections import namedtuple

from awx_plugins.interfaces._temporary_private_container_api import get_incontainer_path

from awx.main.tasks.jobs import RunInventoryUpdate
from awx.main.models import InventorySource, Credential, CredentialType, UnifiedJob, ExecutionEnvironment
from awx.main.constants import STANDARD_INVENTORY_UPDATE_ENV
from awx.main.tests import data
from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names
from django.conf import settings

DATA = os.path.join(os.path.dirname(data.__file__), 'inventory')


def generate_fake_var(element):
    """Given a credential type field element, makes up something acceptable."""
    if element['type'] == 'string':
        if element.get('format', None) == 'ssh_private_key':
            # this example came from the internet
            return '\n'.join(
                [
                    '-----BEGIN ENCRYPTED PRIVATE KEY-----'
                    'MIIBpjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQI5yNCu9T5SnsCAggA'
                    'MBQGCCqGSIb3DQMHBAhJISTgOAxtYwSCAWDXK/a1lxHIbRZHud1tfRMR4ROqkmr4'
                    'kVGAnfqTyGptZUt3ZtBgrYlFAaZ1z0wxnhmhn3KIbqebI4w0cIL/3tmQ6eBD1Ad1'
                    'nSEjUxZCuzTkimXQ88wZLzIS9KHc8GhINiUu5rKWbyvWA13Ykc0w65Ot5MSw3cQc'
                    'w1LEDJjTculyDcRQgiRfKH5376qTzukileeTrNebNq+wbhY1kEPAHojercB7d10E'
                    '+QcbjJX1Tb1Zangom1qH9t/pepmV0Hn4EMzDs6DS2SWTffTddTY4dQzvksmLkP+J'
                    'i8hkFIZwUkWpT9/k7MeklgtTiy0lR/Jj9CxAIQVxP8alLWbIqwCNRApleSmqtitt'
                    'Z+NdsuNeTm3iUaPGYSw237tjLyVE6pr0EJqLv7VUClvJvBnH2qhQEtWYB9gvE1dS'
                    'BioGu40pXVfjiLqhEKVVVEoHpI32oMkojhCGJs8Oow4bAxkzQFCtuWB1'
                    '-----END ENCRYPTED PRIVATE KEY-----'
                ]
            )
        if element['id'] == 'host':
            return 'https://foo.invalid'
        return 'fooo'
    elif element['type'] == 'boolean':
        return False
    raise Exception('No generator written for {} type'.format(element.get('type', 'unknown')))


def credential_kind(source):
    """Given the inventory source kind, return expected credential kind"""
    if source == 'openshift_virtualization':
        return 'kubernetes_bearer_token'
    return source.replace('ec2', 'aws')


@pytest.fixture
def fake_credential_factory():
    def wrap(source):
        ct = CredentialType.defaults[credential_kind(source)]()
        ct.save()

        inputs = {}
        var_specs = {}  # pivoted version of inputs
        for element in ct.inputs.get('fields'):
            var_specs[element['id']] = element
        for var in var_specs.keys():
            inputs[var] = generate_fake_var(var_specs[var])

        if source == 'controller':
            inputs.pop('oauth_token')  # mutually exclusive with user/pass

        return Credential.objects.create(credential_type=ct, inputs=inputs)

    return wrap


def read_content(private_data_dir, raw_env, inventory_update):
    """Read the environmental data laid down by the task system
    template out private and secret data so they will be readable and predictable
    return a dictionary `content` with file contents, keyed off environment variable
        that references the file
    """
    # build dict env as a mapping of environment variables to file names
    # Filter out environment variables which come from runtime environment
    env = {}
    exclude_keys = set(('PATH', 'INVENTORY_SOURCE_ID', 'INVENTORY_UPDATE_ID'))
    for key in dir(settings):
        if key.startswith('ANSIBLE_'):
            exclude_keys.add(key)
    for k, v in raw_env.items():
        if k in STANDARD_INVENTORY_UPDATE_ENV or k in exclude_keys:
            continue
        if k not in os.environ or v != os.environ[k]:
            env[k] = v
    inverse_env = {}
    for key, value in env.items():
        inverse_env.setdefault(value, []).append(key)

    cache_file_regex = re.compile(r'/tmp/awx_{0}_[a-zA-Z0-9_]+/{1}_cache[a-zA-Z0-9_]+'.format(inventory_update.id, inventory_update.source))
    private_key_regex = re.compile(r'-----BEGIN ENCRYPTED PRIVATE KEY-----.*-----END ENCRYPTED PRIVATE KEY-----')

    # read directory content
    # build a mapping of the file paths to aliases which will be constant accross runs
    dir_contents = {}
    referenced_paths = set()
    file_aliases = {}
    filename_list = os.listdir(private_data_dir)
    for subdir in ('env', 'inventory'):
        if subdir in filename_list:
            filename_list.remove(subdir)
            for filename in os.listdir(os.path.join(private_data_dir, subdir)):
                filename_list.append(os.path.join(subdir, filename))
    filename_list = sorted(filename_list, key=lambda fn: inverse_env.get(os.path.join(private_data_dir, fn), [fn])[0])
    inventory_content = ""
    for filename in filename_list:
        if filename in ('args', 'project'):
            continue  # Ansible runner
        abs_file_path = os.path.join(private_data_dir, filename)
        file_aliases[abs_file_path] = filename
        runner_path = get_incontainer_path(abs_file_path, private_data_dir)
        if runner_path in inverse_env:
            referenced_paths.add(abs_file_path)
            alias = 'file_reference'
            for i in range(10):
                if alias not in file_aliases.values():
                    break
                alias = 'file_reference_{}'.format(i)
            else:
                raise RuntimeError('Test not able to cope with >10 references by env vars. Something probably went very wrong.')
            file_aliases[abs_file_path] = alias
            for env_key in inverse_env[runner_path]:
                env[env_key] = '{{{{ {} }}}}'.format(alias)
        try:
            with open(abs_file_path, 'r') as f:
                dir_contents[abs_file_path] = f.read()
            # Declare a reference to inventory plugin file if it exists
            if abs_file_path.endswith('.yml') and 'plugin: ' in dir_contents[abs_file_path]:
                inventory_content = dir_contents[abs_file_path]
                referenced_paths.add(abs_file_path)  # used as inventory file
            elif cache_file_regex.match(abs_file_path):
                file_aliases[abs_file_path] = 'cache_file'
        except IsADirectoryError:
            dir_contents[abs_file_path] = '<directory>'
            if cache_file_regex.match(abs_file_path):
                file_aliases[abs_file_path] = 'cache_dir'

    # Substitute in aliases for cross-file references
    for abs_file_path, file_content in dir_contents.copy().items():
        if cache_file_regex.match(file_content):
            if 'cache_dir' not in file_aliases.values() and 'cache_file' not in file_aliases in file_aliases.values():
                raise AssertionError('A cache file was referenced but never created, files:\n{}'.format(json.dumps(dir_contents, indent=4)))
        # if another files path appears in this file, replace it with its alias
        for target_path in dir_contents.keys():
            other_alias = file_aliases[target_path]
            if target_path in file_content:
                referenced_paths.add(target_path)
                dir_contents[abs_file_path] = file_content.replace(target_path, '{{ ' + other_alias + ' }}')

    # The env/settings file should be ignored, nothing needs to reference it as its picked up directly from runner
    ignore_files = [os.path.join(private_data_dir, 'env', 'settings')]

    # build dict content which is the directory contents keyed off the file aliases
    content = {}
    for abs_file_path, file_content in dir_contents.items():
        # assert that all files laid down are used
        if (
            abs_file_path not in referenced_paths
            and get_incontainer_path(abs_file_path, private_data_dir) not in inventory_content
            and abs_file_path not in ignore_files
        ):
            raise AssertionError(
                "File {} is not referenced. References and files:\n{}\n{}".format(abs_file_path, json.dumps(env, indent=4), json.dumps(dir_contents, indent=4))
            )
        file_content = private_key_regex.sub('{{private_key}}', file_content)
        content[file_aliases[abs_file_path]] = file_content

    return (env, content)


def create_reference_data(source_dir, env, content):
    if not os.path.exists(source_dir):
        os.mkdir(source_dir)
    if content:
        files_dir = os.path.join(source_dir, 'files')
        if not os.path.exists(files_dir):
            os.mkdir(files_dir)
        for env_name, content in content.items():
            with open(os.path.join(files_dir, env_name), 'w') as f:
                f.write(content)
    if env:
        with open(os.path.join(source_dir, 'env.json'), 'w') as f:
            json.dump(env, f, indent=4, sort_keys=True)


@mock.patch('awx_plugins.interfaces._temporary_private_licensing_api.detect_server_product_name', return_value='NOT-AWX')
@pytest.mark.django_db
@pytest.mark.parametrize('this_kind', discover_available_cloud_provider_plugin_names())
def test_inventory_update_injected_content(product_name, this_kind, inventory, fake_credential_factory, mock_me):
    ExecutionEnvironment.objects.create(name='Control Plane EE', managed=True)
    ExecutionEnvironment.objects.create(name='Default Job EE', managed=False)

    injector = InventorySource.injectors[this_kind]

    src_vars = dict(base_source_var='value_of_var')
    src_vars['plugin'] = injector.get_proper_name()
    inventory_source = InventorySource.objects.create(
        inventory=inventory,
        source=this_kind,
        source_vars=src_vars,
    )
    inventory_source.credentials.add(fake_credential_factory(this_kind))
    inventory_update = inventory_source.create_unified_job(_eager_fields={'status': 'waiting'})
    task = RunInventoryUpdate()

    def substitute_run(awx_receptor_job):
        """This method will replace run_pexpect
        instead of running, it will read the private data directory contents
        It will make assertions that the contents are correct
        If MAKE_INVENTORY_REFERENCE_FILES is set, it will produce reference files
        """
        envvars = awx_receptor_job.runner_params['envvars']

        private_data_dir = envvars.pop('AWX_PRIVATE_DATA_DIR')
        assert envvars.pop('ANSIBLE_INVENTORY_ENABLED') == 'auto'
        set_files = bool(os.getenv("MAKE_INVENTORY_REFERENCE_FILES", 'false').lower()[0] not in ['f', '0'])

        # Ensure the directory exists before trying to list/read it
        os.makedirs(private_data_dir, exist_ok=True)

        env, content = read_content(private_data_dir, envvars, inventory_update)

        # Assert inventory plugin inventory file is in private_data_dir
        inventory_filename = InventorySource.injectors[inventory_update.source]().filename
        assert (
            len([True for k in content.keys() if k.endswith(inventory_filename)]) > 0
        ), f"'{inventory_filename}' file not found in inventory update runtime files {content.keys()}"

        env.pop('ANSIBLE_COLLECTIONS_PATH', None)
        base_dir = os.path.join(DATA, 'plugins')
        if not os.path.exists(base_dir):
            os.mkdir(base_dir)
        source_dir = os.path.join(base_dir, this_kind)  # this_kind is a global
        if set_files:
            create_reference_data(source_dir, env, content)
            pytest.skip('You set MAKE_INVENTORY_REFERENCE_FILES, so this created files, unset to run actual test.')
        else:
            source_dir = os.path.join(base_dir, this_kind)  # this_kind is a global

            if not os.path.exists(source_dir):
                raise FileNotFoundError('Maybe you never made reference files? MAKE_INVENTORY_REFERENCE_FILES=true py.test ...\noriginal: {}')
            files_dir = os.path.join(source_dir, 'files')
            try:
                expected_file_list = os.listdir(files_dir)
            except FileNotFoundError:
                expected_file_list = []
            for f_name in expected_file_list:
                with open(os.path.join(files_dir, f_name), 'r') as f:
                    ref_content = f.read()
                    assert ref_content == content[f_name], f_name
            try:
                with open(os.path.join(source_dir, 'env.json'), 'r') as f:
                    ref_env_text = f.read()
                    ref_env = json.loads(ref_env_text)
            except FileNotFoundError:
                ref_env = {}
            assert ref_env == env
        Res = namedtuple('Result', ['status', 'rc'])
        return Res('successful', 0)

    # Mock this so that it will not send events to the callback receiver
    # because doing so in pytest land creates large explosions
    with mock.patch('awx.main.queue.CallbackQueueDispatcher.dispatch', lambda self, obj: None):
        # Also do not send websocket status updates
        with mock.patch.object(UnifiedJob, 'websocket_emit_status', mock.Mock()):
            # The point of this test is that we replace run with assertions
            with mock.patch('awx.main.tasks.receptor.AWXReceptorJob.run', substitute_run):
                with mock.patch('awx.main.tasks.jobs.create_partition'):
                    # so this sets up everything for a run and then yields control over to substitute_run
                    task.run(inventory_update.pk)
